// Sage Android Client.
// Connect to a Sage server, remotely execute commands and display results.
//
// Copyright (C) 2010, Harald Schilly <harald.schilly@gmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package org.sagemath.android;

import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicNameValuePair;
import org.json.JSONObject;

import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

/**
 * This is our background service to keep a connection with a Sage server. It is
 * designed to connect to <b>one</b> server with given credentials, or if there
 * are non, does not connect. If keepAlive is true, it tries to reconnect if the
 * connection is broken. After a certain time of inactivity it assumes that it
 * is no longer needed and disconnects.
 * 
 * @author Harald Schilly
 */
final public class SageConnection extends Service {
  final static String TAG = SageConnection.class.getSimpleName();
  
  public final static int DISCONNECTED = 0, CONNECTING = 1, CONNECTED = 2;
  
  private int status = SageConnection.DISCONNECTED;
  
  private Timer timerCalc, timerConn;
  
  private boolean keepAlive = true;
  
  private boolean showNotification = false;
  
  private List<CalculationListener> calcListeners = new ArrayList<CalculationListener>(2);
  
  private ConnectionListener connListener = null;
  
  private CountDownLatch countdown = null;
  
  private boolean killMeIsScheduled = false;
  
  // connection credentials
  private Server server = null;
  
  // TODO once the api and interface is settled, we switch to a longer
  // queue where all commands are executed in given order. beware, all
  // interacts need to behave nicely and skip commands if a calculation
  // is currently running.
  /**
   * work queue, restricted to size 1 for now ... so there is only 0 or 1
   * {@link Work} object in the queue.
   */
  final private LinkedBlockingQueue<Calculation> work = new LinkedBlockingQueue<Calculation>(1);
  
  final private AtomicBoolean calcRunning = new AtomicBoolean(false);
  
  public SageConnection() {
    super();
    startConnection();
  }
  
  /**
   * This task is repeated by the timer and checks if the connection to the Sage
   * server is still alive.
   */
  final private TimerTask checkConnectedTask = new TimerTask() {
    @Override
    final public void run() {
      // TODO this task should not take longer than 10 secs.
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      }
      status = (int) (Math.random() * 3);
      updateConnectonListener(status);
    }
  };
  
  /**
   * This task is started once and runs forever until it is interrupted. It
   * blocks on the {@link work} queue and waits for new {@link Work} objects to
   * submit to the Sage server. TODO Some intelligence when there is no
   * connection.
   */
  final private TimerTask calculationTask = new TimerTask() {
    private Calculation c;
    
    @Override
    final public void run() {
      while (true) {
        try {
          c = work.take();
        } catch (InterruptedException e1) {
          Log.w(TAG, "Calculation Task interrupted!");
          Thread.currentThread().interrupt();
          return;
        }
        
        // blocking set calc flag to true before starting calculation
        calcRunning.set(true);
        
        // bogus calculation
        try {
          Thread.sleep(500 + (long) (Math.random() * 500.0));
        } catch (InterruptedException e) {
          calcRunning.set(false);
          Thread.currentThread().interrupt();
          return;
        }
        int i;
        try {
          i = 2 * Integer.parseInt(c.getCode());
        } catch (NumberFormatException nfe) {
          i = 42;
        }
        c.setResult(Integer.toString(i));
        // end bogus calculation
        
        // after calculation, set calc flag to false
        calcRunning.set(false);
        
        // if not null, killMe wants me to count down.
        if (countdown != null) {
          countdown.countDown();
        }
        
        // notify listeners
        updateListenersResult(c);
        
        showNotification(c.getElapsed() + " " + c.getResult());
      }
    }
  };
  
  /**
   * This is called when the service is about to shut down or is not needed any
   * more. It checks if there is still some work to do and waits for it to
   * finish.
   */
  final private TimerTask killMe = new TimerTask() {
    
    @Override
    final public void run() {
      killMeIsScheduled = true;
      // TODO wait for calculation to finish
      try {
        while (work.size() > 0 && calcRunning.get()) {
          // TODO that's not correct, because we will not get the remaining
          // calculations
          countdown = new CountDownLatch(1);
          countdown.await();
        }
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
      } finally {
        Log.i(TAG, "stopSelf called by killMe TimerTask");
        stopSelf();
      }
    }
  };
  
  /**
   * used for reading the content of HTTP responses
   */
  final private StringBuilder calcSb = new StringBuilder();
  
  /**
   * This stores the session ID. If null, we do not have a session.
   */
  final private AtomicReference<String> sessionID = new AtomicReference<String>();
  
  /**
   * Communicate with the Sage server. This contains the HTTP communication and
   * parsing of the returned data.
   * 
   * @throws CalculationException
   */
  final private String doCalculation(String input) throws CalculationException {
    String answer = "<error>";
    if (sessionID.equals(null)) {
      // we don't have a session
      sessionID.set(doEstablishSession());
    }
    
    // HTTP GET
    String queryCalc = server + "/simple/compute?" + "session=" + sessionID.get() + "&code="
        + Uri.encode(input);
    // for more read doEstablishSession()
    
    // HTTP POST Example
    HttpClient httpclient = new DefaultHttpClient();
    try {
      HttpPost httpPost = new HttpPost(server + "/simple/compute");
      
      // POST Payload
      List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(2);
      nameValuePairs.add(new BasicNameValuePair("session", sessionID.get()));
      nameValuePairs.add(new BasicNameValuePair("code", input));
      httpPost.setEntity(new UrlEncodedFormEntity(nameValuePairs));
      
      // Send to server
      HttpResponse response = httpclient.execute(httpPost);
      
      int status = response.getStatusLine().getStatusCode();
      if (status != 200) {
        // 200 is HTTP OK
      }
      
      // headers, i.e. check if Sage actually did something, eventually retry to
      // fetch the calculation later, ...
      Header[] sageStatus = response.getHeaders("SAGE-STATUS");
      String sageStatusValue = sageStatus[0].getValue();
      
      // response content, here should be the actual answer
      answer = readContent(response);
      
    } catch (Exception ex) {
      // TODO: explain what has happened and properly handle exception
      throw new CalculationException("unable to do a calculation");
    } finally {
      // TODO I think it should be possible to reuse the httpclient object ...
      httpclient.getConnectionManager().shutdown();
    }
    
    return answer;
  }
  
  /**
   * Helper to read the HTTP content of a response and return it as a String
   * 
   * @throws IOException
   */
  private String readContent(HttpResponse response) throws IOException {
    HttpEntity entity = response.getEntity();
    InputStream in = entity.getContent();
    // The following just copies the content into the StringBuilder
    BufferedReader reader = new BufferedReader(new InputStreamReader(in));
    calcSb.setLength(0);
    for (String line; (line = reader.readLine()) != null;) {
      calcSb.append(line + "\n"); // TODO get system specific newline
    }
    in.close();
    return calcSb.toString();
  }
  
  /**
   * Communicate with the Sage to establish a session.
   * 
   * @return if true, a session has been established
   * @throws CalculationException
   */
  final private String doEstablishSession() throws CalculationException {
    
    if (server == null) {
      throw new CalculationException("unable to establish a session");
    }
    
    HttpClient httpclient = new DefaultHttpClient();
    try {
      // HTTP GET
      String querySession = server + "/simple/login?username=" + server.username + "&password="
          + server.password;
      HttpGet httpget = new HttpGet(querySession);
      
      HttpResponse response = httpclient.execute(httpget);
      String answer = readContent(response);
      
      String[] data = answer.split("___S_A_G_E___");
      JSONObject json = new JSONObject(data[0]);
      return json.getString("session");
      
    } catch (Exception ex) {
      // TODO: explain what has happened and properly handle exception
      throw new CalculationException("unable to establish a session");
    } finally {
      // TODO I think it should be possible to reuse the httpclient object ...
      httpclient.getConnectionManager().shutdown();
    }
  }
  
  /**
   * Defines a mapping of what to do when a client issues an action or requests
   * a calculation.
   */
  final private CalculationAPI.Stub apiEndpoint = new CalculationAPI.Stub() {
    
    @Override
    final public void addCalcListener(CalculationListener listener) throws RemoteException {
      synchronized (calcListeners) {
        calcListeners.add(listener);
      }
    }
    
    @Override
    final public void removeCalcListener(CalculationListener listener) throws RemoteException {
      synchronized (calcListeners) {
        calcListeners.remove(listener);
      }
    }
    
    @Override
    final public void addConnListener(ConnectionListener l) throws RemoteException {
      connListener = l;
    }
    
    @Override
    final public void removeConnListener(ConnectionListener l) throws RemoteException {
      connListener = null;
    }
    
    @Override
    final public void stopService() throws RemoteException {
      killConnection();
      stopSelf();
    }
    
    @Override
    final public void killConnection() throws RemoteException {
      killConnection();
    }
    
    @Override
    final public int getStatus() throws RemoteException {
      return status;
    }
    
    @Override
    final public void setServer(Server server) throws RemoteException {
      SageConnection.this.server = server;
    }
    
    @Override
    final public boolean getKeepAlive() throws RemoteException {
      return keepAlive;
    }
    
    @Override
    final public void setKeepAlive(boolean keepAlive) throws RemoteException {
      SageConnection.this.keepAlive = keepAlive;
    }
    
    /**
     * background service API hook to start and enqueue a new
     * {@link Calculation}
     * 
     * @return the unique ID of the new Calculation
     */
    @Override
    final public int calculate(String code) throws RemoteException {
      Calculation c = new Calculation();
      c.setCode(code);
      c.setStartTime(System.currentTimeMillis());
      
      boolean added = false;
      while (!added) {
        added = work.offer(c);
        if (!added) {
          work.clear();
        }
      }
      return c.getId();
    }
    
    @Override
    final public boolean isCalculationRunning() throws RemoteException {
      return calcRunning.get();
    }
    
    @Override
    final public void clearQueue() throws RemoteException {
      work.clear();
    }
    
    @Override
    final public int getQueueSize() throws RemoteException {
      return work.size();
    }
    
    @Override
    final public void setShowNotification(boolean b) throws RemoteException {
      showNotification = b;
    }
  };
  
  /**
   * Tell listeners about the connection
   * 
   * @param b
   */
  final private void updateConnectonListener(int s) {
    synchronized (connListener) {
      if (connListener != null) {
        try {
          connListener.handleConnectionState(s);
        } catch (RemoteException e) {
          Log.w(TAG, "Failed to notify listener about ConnectionState: " + s, e);
          // TODO I'm no longer needed, except there is a calculation running
          // make sure that it is only called once (we are synchronized in here)
          if (!killMeIsScheduled) {
            timerConn.schedule(killMe, 0);
          }
        }
      }
    }
  }
  
  /**
   * Tell listeners about a result, which is stored in the {@link Calculation}
   * object.
   * 
   * @param c
   */
  final private void updateListenersResult(Calculation c) {
    synchronized (calcListeners) {
      for (Iterator<CalculationListener> li = calcListeners.iterator(); li.hasNext();) {
        CalculationListener l = li.next();
        try {
          if (l.handleCalculationResult(c)) {
            return; // true means calculation was consumed
          }
        } catch (RemoteException e) {
          Log.i(TAG, "Failed to notify listener " + l + " - removing", e);
          li.remove();
        }
      }
    }
  }
  
  /**
   * Establish a new connection.
   * <ol>
   * <li>check if we have credentials (otherwise send something back to client
   * ... NYI)</li>
   * <li>get a session key and store it locally. each subsequent request needs
   * it</li>
   * <li>start a periodic async task to check if the connection is still alive</li>
   * <li>tell the calculation task that it can start doing work</li>
   * </ol>
   */
  final private void startConnection() {
    // TODO start actual connection
    timerCalc = new Timer("Calculation");
    timerCalc.schedule(calculationTask, 0);
    
    timerConn = new Timer("Connection");
    // check connection every 10 secs, with a delay of 1 sec.
    // it is NOT fixed rate which means that the starting time does not
    // depend on the execution time.
    // TODO set this to 30 secs
    timerConn.schedule(checkConnectedTask, 1000, 1 * 1000);
  }
  
  /**
   * Kill connection, especially tell Sage goodbye and to kill its current
   * session and session key.
   */
  final private void killConnection() {
    // if connection exists, kill it
    if (timerCalc != null) {
      calculationTask.cancel();
      timerCalc.cancel();
      timerCalc = null;
    }
    if (timerConn != null) {
      checkConnectedTask.cancel();
      timerConn.cancel();
      timerConn = null;
    }
  }
  
  /**
   * Server/Client binding mechanism, check if we are really called on the
   * correct intent.
   */
  @Override
  final public IBinder onBind(Intent intent) {
    if (SageConnection.class.getName().equals(intent.getAction())) {
      return apiEndpoint;
    } else {
      return null;
    }
  }
  
  /**
   * 
   */
  @Override
  final public void onCreate() {
    super.onCreate();
    // TODO some kind of reset?
  }
  
  /**
   * 
   */
  @Override
  final public void onDestroy() {
    super.onDestroy();
    killConnection();
  }
  
  /**
   * ID for the notification, unique in application service
   */
  final private static int HELLO_ID = 10781;
  
  /**
   * the idea is to show a notification in androids main UI's
   * notification bar when the main app is currently closed. By clicking on the
   * notification the main UI is launched again and should show the result.
   */
  final private void showNotification(String result) {
    if (!showNotification) {
      return;
    }
    // get system's notification manager
    String ns = Context.NOTIFICATION_SERVICE;
    NotificationManager mNotificationManager = (NotificationManager) getSystemService(ns);
    
    // what to show in the bar
    int icon = R.drawable.notification_icon;
    CharSequence tickerText = "Sage finished.";
    long when = System.currentTimeMillis();
    Notification notification = new Notification(icon, tickerText, when);
    // cancel it after selecting
    notification.flags |= Notification.FLAG_AUTO_CANCEL;
    
    // notification content itself + intent for launching the main app
    Context context = getApplicationContext();
    CharSequence contentTitle = "Sage Calculation finished.";
    String part = result.substring(0, Math.min(result.length(), 30)) + "...";
    CharSequence contentText = "Result: " + part;
    Intent notificationIntent = new Intent(this, SageAndroid.class);
    // TODO somehow store an ID which interact should show the result. this
    // should probably be part of the original calculation.
    notificationIntent.putExtra("target", 0);
    PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);
    notification.setLatestEventInfo(context, contentTitle, contentText, contentIntent);
    
    // show it
    mNotificationManager.notify(HELLO_ID, notification);
  }
}

/**
 * A CalculationException describes the problem of a failed calculation.
 */
@SuppressWarnings("serial")
final class CalculationException extends Exception {
  
  private Calculation c;
  
  public CalculationException(String msg) {
    super(msg);
  }
  
  /**
   * Creates a new CalculationException and attaches the {@link Calculation} c
   * to it.
   * 
   * @param msg
   * @param c
   */
  public CalculationException(String msg, Calculation c) {
    super(msg);
    this.c = c;
  }
  
  final Calculation getCalculation() {
    return c;
  }
}
